Internationalization with react-redux-starter-kit
You can also be interested in:
This was the first time I had to manage serious translations for a react-redux project. I use to develop my applications starting from the awesome react-redux-starter-key by davezuko.
This starter kit doesn't include i18n support out of the box, so some work is needed. I followed this article from freecodcamp and changed things a bit, so let's start.
Install react-intl
It seems that in the react world there is one library to choose for internationalization: react-intl by yahoo.
Let's install it:
npm install --save react-intl
Also, let's install its babel plugin which does a very important job:
Extracts string messages from React components that use React Intl
npm install --save-dev babel-plugin-react-intl
Babel needs now some more configuration: open your ~/config/project.config.js
and change the compiler_babel
key as follows:
compiler_babel : { cacheDirectory : true, plugins : ['transform-runtime', [ 'react-intl', { 'messagesDir': './i18n/messages', 'enforceDescriptions': false } ]], presets : ['es2015', 'react', 'stage-0'] },
In line 5 we say that we want all the extracted strings to be inside a folder named i18n in the root of our react-redux-starter-kit project. The folder structure of the components will be cloned and every components will have its <ComponentName>.json
file with the extracted strings. Wonderful!
This library provides many functionalities, but for our scope some are important at this point:
-
it provides a IntlProvider component which wraps the application and has the two relevant props: locale (the current locale) and messages (the current locale strings object):
ReactDOM.render( <IntlProvider locale="en" messages={enMessages}> <App /> </IntlProvider>, document.getElementById('container') );
this is the example code the library provides you, we will need to do something different in order to integrate internationalization with redux;
-
It provides components and functions used to mark a string/currency/date as translatable, i.e:
<FormattedMessage id="welcome" defaultMessage={`Hello {name}, you have {unreadCount, number} {unreadCount, plural, one {message} other {messages} }`} values={{name: {name}, unreadCount}} />
Load data and attach the i18n context to redux
We need to load the appropriate locale data for the languages we need to support, I compile for the browser, so I have to add manually all this data, because react-intl only comes with en data (in my example I'll support both english and italian).
Also I'd like to store the current locale settings inside the redux store, in order to have the UI updated when the locale changes. For that reason I need to connect the react-intl provider to redux.
In the react-intl examples the provided IntlProvider is used as a wrapper for the entire application, but in my case, I need it to be a child of the redux Provider (otherwise the store is not found, and I get something like https://github.com/reactjs/react-redux/issues/57).
For these reasons, a good place to load data and inject the IntlProvider is the ~/containers/AppContainer.js
component:
import React, { Component, PropTypes } from 'react' import { browserHistory, Router } from 'react-router' import { connect, Provider } from 'react-redux' import { IntlProvider, addLocaleData } from 'react-intl' import en from 'react-intl/locale-data/en' import it from 'react-intl/locale-data/it' // ======================================================== // Internationalization // ======================================================== addLocaleData([...en, ...it]) let mapStateToProps = (state) => { return { locale: state.i18n.locale, messages: state.i18n.messages } } let ConnectedIntlProvider = connect(mapStateToProps)(IntlProvider) class AppContainer extends Component { static propTypes = { routes : PropTypes.array.isRequired, store : PropTypes.object.isRequired } shouldComponentUpdate () { return false } render () { const { routes, store } = this.props return ( <Provider store={store}> <ConnectedIntlProvider> <div style={{ height: '100%' }}> <Router history={browserHistory} children={routes} /> </div> </ConnectedIntlProvider> </Provider> ) } } export default AppContainer
As you can see, the redux connection takes place in lines 13-14. The redux state objects mapped to props are i18n.locale and i18n.messages, so it's time to create a reducer for this stuff.
Let's create it: add ~/store/i18n.js
// Translated strings import localeData from '../../i18n/locales/data.json' // Define user's language. Different browsers have the user locale defined // on different fields on the `navigator` object, so we make sure to account // for these different by checking all of them let defaultLanguage = (navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage let langWithoutRegionCode = (language) => language.toLowerCase().split(/[_-]+/)[0] // ------------------------------------ // Constants // ------------------------------------ export const LOCALE_CHANGE = 'LOCALE_CHANGE' // ------------------------------------ // Actions // ------------------------------------ export function localeChange (locale) { return { type : LOCALE_CHANGE, payload : locale } } // ------------------------------------ // Specialized Action Creator // ------------------------------------ export const updateLocale = ({ dispatch }) => { return (nextLocale) => dispatch(localeChange(nextLocale)) } // ------------------------------------ // Reducer // ------------------------------------ const initialState = { locale: defaultLanguage, messages: localeData[defaultLanguage] || localeData[langWithoutRegionCode(defaultLanguage)] || localeData['en-en'] } export default function i18nReducer (state = initialState, action) { return action.type === LOCALE_CHANGE ? { locale: action.payload, messages: localeData[action.payload] || localeData[langWithoutRegionCode(action.payload)] || localeData.en } : state }
Let's examinate a bit this code:
- line 2: we import the json containing all the translated strings, we'll se how to generate such json in a while;
- line 7: we get the user current language, in order to set it as the default locale state;
- line 21: we define the action used to change the current locale;
- line 31: same as above, but it's a shortcut;
- line 38: the initial state with the default locale and the default messages, taken from localData (line 2);
- line 43: the reducer. When the locale changes, both the state locale and messages change.
Ok, now we need to add this reducer to the store, so edit the file ~/store/reducers.js and add the i18n reducer:
import { combineReducers } from 'redux' import locationReducer from './location' import i18nReducer from './i18n' export const makeRootReducer = (asyncReducers) => { return combineReducers({ location: locationReducer, i18n: i18nReducer, ...asyncReducers }) } export const injectReducer = (store, { key, reducer }) => { if (Object.hasOwnProperty.call(store.asyncReducers, key)) return store.asyncReducers[key] = reducer store.replaceReducer(makeRootReducer(store.asyncReducers)) } export default makeRootReducer
Build the translations file
We've seen that babel-plugin-react-intl will parse all the source code, and collect formatted messages in one json for each component. Now we need a way to collect all this strings and generate an unique file that can be sent to translators. We do this with a script (made by yahoo) that I've tweaked a bit in order to work properly inside our starter kit.
First of all I added a locale settings inside ~/config/project.config.js
:
... const config = { env : process.env.NODE_ENV || 'development', // ---------------------------------- // Project Structure // ---------------------------------- path_base : path.resolve(__dirname, '..'), dir_client : 'src', dir_dist : 'dist', dir_public : 'public', dir_server : 'server', dir_test : 'tests', // ---------------------------------- // Internationalization // ---------------------------------- i18n: { // also add data in src/containers/AppContainer locales: { 'it-it': 'ita', 'en-en': 'en' } }, ...
Now create a file named i18n.generate.js inside ~/bin
:
var fs = require('fs') var globSync = require('glob').sync var mkdirpSync = require('mkdirp').sync var locales = require('../config/project.config').i18n.locales var filePattern = './i18n/messages/**/*.json' var outputDir = './i18n/locales/' // Aggregates the default messages that were extracted from the example app's // React components via the React Intl Babel plugin. An error will be thrown if // there are messages in different components that use the same `id`. The result // is a flat collection of `id: message` pairs for the app's default locale. var defaultMessages = globSync(filePattern) .map((filename) => fs.readFileSync(filename, 'utf8')) .map((file) => JSON.parse(file)) .reduce((collection, descriptors) => { descriptors.forEach(function (d) { var id = d.id var defaultMessage = d.defaultMessage if (collection.hasOwnProperty(id)) { throw new Error(`Duplicate message id: ${id}`) } collection[id] = defaultMessage }) return collection }, {}) // Create a new directory that we want to write the aggregate messages to mkdirpSync(outputDir) // Write the messages to this directory var messages = {} Object.keys(locales).forEach(function (l) { messages[l] = defaultMessages }) fs.writeFileSync(outputDir + 'data.json', JSON.stringify(messages, null, 2))
As you can see in lines 33-37 we use the previuos defined locale config to produce a json were all the locales keys have the default messages as value. Now it's enough to translate those strings and all will work properly!
Now we can add this script to the package.json file, so that it can be called in the same way you execute a deploy or compile the application, inside package.json:
... "scripts": { "geni18n": "better-npm-run geni18n", "clean": "rimraf dist", ... "betterScripts": { "geni18n": { "command": "node bin/i18n-generate", "env": { "DEBUG": "app:*" } }, "compile": { "command": "node bin/compile",
This way we will'use the following command to generate the translations file:
$ npm run geni18n
Let the user change language
And finally, how do we allow the user to change locale?
Inside a component (i.e. Header.js
)you can use:
import React, { PropTypes } from 'react' const propTypes = { onLocaleChange: PropTypes.func.isRequired } const Header = (props) => { return ( <header className='app-header'> <a onClick={() => props.onLocaleChange('it-it')}>ITA</a> <a onClick={() => props.onLocaleChange('en-en')}>EN</a> </header> ) } Header.propTypes = propTypes export default Header
Where onLocaleChage is defined in the container of this component (HeaderContainer.js
):
import { connect } from 'react-redux' import { updateLocale } from 'store/i18n' import Header from 'components/Header' const mapStateToProps = (state) => { return { locale: state.i18n.locale } } const mapDispatchToProps = (dispatch) => { return { onLocaleChange: updateLocale({ dispatch }) } } export default connect( mapStateToProps, mapDispatchToProps )(Header)
This post contains a lot of stuff, probably I forgot something, or made some mistakes here and there, please let me know if it worked for you, and suggestions are very mush appreciated!
Update 2017-03-22
After using this configuration a bit, I found problems with the babel-plugin-react-intl, in particular I couldn't find a way to update the generated files, it seems that it works only the first time, and then does not extract strings anymore, even after removing the previous messages
dir.
I found a solution for this now:
-
Create a
.babelrc
file in your root directory with the following contents:// This configuration is used only when generating translations // see buildi18n command in package.json { "presets": ["es2015", "react", "stage-0"], "plugins": ["transform-runtime" ], "env": { "i18n": { "plugins": [ [ "react-intl", { "messagesDir": "./i18n/messages", "enforceDescriptions": false }] ] } } }
- Remove the correspondig part inside
config/project.config.js
. Now this plugin will be run only manually and not at every compilation. - Add to your
package.json
... "scripts": { "geni18n": "better-npm-run geni18n", "buildi18n": "rm -rf i18n/messages && BABEL_ENV=i18n ./node_modules/babel-cli/bin/babel.js --quiet src > /dev/null", ...
Ok, now you can extract the strings with the command:
$ npm run buildi18n
and, as before, generate the translations file with:
$ npm run geni18n
Your Smartwatch Loves Tasker!
Your Smartwatch Loves Tasker!
Featured
Archive
- 2021
- 2020
- 2019
- 2018
- 2017
- Nov
- Oct
- Aug
- Jun
- Mar
- Feb
- 2016
- Oct
- Jun
- May
- Apr
- Mar
- Feb
- Jan
- 2015
- Nov
- Oct
- Aug
- Apr
- Mar
- Feb
- Jan
- 2014
- Sep
- Jul
- May
- Apr
- Mar
- Feb
- Jan
- 2013
- Nov
- Oct
- Sep
- Aug
- Jul
- Jun
- May
- Apr
- Mar
- Feb
- Jan
- 2012
- Dec
- Nov
- Oct
- Aug
- Jul
- Jun
- May
- Apr
- Jan
- 2011
- Dec
- Nov
- Oct
- Sep
- Aug
- Jul
- Jun
- May